Aula 4

A partir dessa aula nosso estudo será mais direcionado para o viés funcional de Scala. Nesta aula será apresentado:

  • Case Classes e Pattern Matching
  • Introdução ao Paradigma Funcional
  • Coleções

Pattern Matching


Em várias linguagens existe um operador chamado switch, que funciona como uma sucessiva aplicação de if e else. Em Scala, o operador que faz isso é chamado de match. O exemplo abaixo demonstra um simples funcionamento do match, onde reescrevemos o encadeamento de estruturas de controle utilizando pattern matching:


In [ ]:
val x = 5

if(x==5) println("x = 5") //caso x seja 5
else if(x==10) println("x = 10") //caso x seja 10
else println("x não é 5 nem 10") //caso x não seja nem 5 nem 10

x match {
    case 5 => println("x = 5") //caso x seja 5
    case 10 => println("x = 10") //caso x seja 10
    case _ => println("x não é 5 nem 10") //caso x não seja nem 5 nem 10
}

Diferente de outras linguagens, o pattern matching aceita não só comparação com números inteiros, mas sim com qualquer valor:


In [ ]:
val s: Any = "olá"

s match {
    case "olá" => println("olá! :D")
    case "oi" => println("oi! :)")
    case i:Int => println("é o que mah?")
}

Refinando o Pattern Matching: Case Class

Podemos aplicar o operador match sobre qualquer tipo. Existe um recurso em Scala chamado de case class: uma classe que pode ser usada no pattern matching para obter os valores utilizados para a criação do objeto. Vamos ao exemplo a seguir:


In [ ]:
abstract class Valor()

case class UmValor(a: Int) extends Valor //exemplo de uma classe com um valor

case class DoisValores(a: Int, b: String) extends Valor //exemplo de uma classe com dois valores

case class TresValores(a: Int, b: Int, c: Int) extends Valor //exemplo de uma classe com três valores

val m: Valor = new DoisValores(5,"abacaxi")

m match {
    case UmValor(x) => print(s"Apenas um valor: $x")
    case DoisValores(x,y) => print(s"Dois valores: $x, $y")
    case TresValores(x,y,z) => print(s"Três valores: $x, $y, $z")
}

Paradigma Funcional


Nesta seção, trabalharemos 2 aspectos do paradigma funcional:

  • Função como Valor
  • Função Anônima

Função como valor

O paradigma funcional permite que uma função seja tratada como uma informação, um valor. Até o momento vimos que uma certa variável x poderia armazenar números, caracteres, boleanos e objetos. Em linguagens funcionais, que é o caso de Scala, x também pode armazenar uma função:


In [ ]:
//uma simples função que soma 2 inteiros
def somar(a: Int, b: Int): Int = a + b 

//armazenando a função como variável
val x: (Int,Int) => Int = somar

No exemplo de código acima, podemos notar que x possui um tipo bem diferente do que vimos até agora. Essa é a notação de um tipo função em Scala. Inicialmente, temos os tipos dos parâmetros que a função vai receber e, por fim, o tipo que será retornado. No exemplo acima, x é uma função que recebe 2 inteiros e retorna outro inteiro, que é exatamente o que a função somar faz. Agora, podemos tratar x como uma função e realizar chamadas:


In [ ]:
println(x(1,2))
println(x(3,5))

Curiosidade

Scala permite, literalmente, a definição de tipos. No exemplo acima, vimos que o valor x é uma função que recebe dois inteiros e retorna outro. A função somar segue essa mesma assinatura, assim como poderia seguir a função subtrair ou multiplicar. Para fins de legibilidade, podemos definir um tipo OperacaoBinaria que representa essa assinatura de função!


In [ ]:
type OperacaoBinaria = (Int, Int) => Int

def somar(a: Int, b: Int): Int = a + b 

val x: OperacaoBinaria = somar

println(x(1,2))
println(x(3,5))

Função Anônima

Até o momento nós apenas atribuímos funções pré-definidas a variáveis, ou seja, funções que passaram por um processo de declaração, onde receberam um identificador. O paradigma funcional permite a criação de funções sem declaração de identificador. Essas funções são chamadas de Funções Anônimas.


In [ ]:
def somar(a: Int, b: Int): Int = a + b 

//x recebe uma função previamente declarada, a qual soma 2 inteiros
val x: (Int,Int) => Int = somar

//y recebe uma função sem antes ser declarada, a qual soma 2 inteiros
val y: (Int, Int) => Int = (a,b) => a + b

println(x(1,2))
println(y(1,2))

Para definirmos uma função anônima basta utilizar uma sintaxe similar a definição do tipo função: primeiro informa os parâmetros da função e, em seguida, o corpo do código.

OBS: É sempre necessário fazer a correspondência dos tipos, seja na hora de tipar a variável que receberá a função ou na própria função:

Uma aplicação comum de funções anônimas é quando precisamos mandar uma função para ser executada dentro de outra. Veremos isso mais a frente.

Coleções


Nessa parte da aula, apresentaremos algumas das estruturas de dados de Scala. Trabalharemos com:

  • Tuples (tuplas)
  • Iterables (iteráveis):
    • Lists (listas)
    • Maps (mapas)

OBS:

  • Scala trabalha com o princípio de imutabilidade, ou seja, suas coleções são geralmente imutáveis. Operações que modificam as estruturas normalmente retornam uma nova estrutura com a modificação realizada.
  • Em Scala, não é necessário utilizar o new para criar uma estrutura de dados, basta, se necessário, colocar entre parênteses os elementos iniciais

Tuples

Tuplas são conjuntos ordenados de informações. Elas têm tamanho pré-definido e são parametrizadas para cada elemento:


In [ ]:
val x: (Int,Int) = (1,2)
val y: (Int,String,Int) = (10,"abc",23)
val z: (Int,String,Double,Int,Char) = (10,"abc",15.3,2,'o')

Para acessar os elementos de uma tupla, basta utilizar a notação ._n, onde n é a posição do elemento da tupla (começando de 1)


In [ ]:
println(x._1)
println(x._2)

Tuplas também permitem atribuição direta de cada elemento a uma variável:


In [ ]:
val (a,b,c) = y

println(s"a: $a, b: $b, c: $c")

Iterables

Iteráveis são estruturas de dados que podem possuir vários elementos de um mesmo tipo (diferente da tupla, onde precisamos dizer o tipo de cada elemento). Essas estruturas possuem um conjunto de métodos de Alta Ordem (métodos que recebem outras funções que serão executadas em seu interior) para manipular seus elementos. Os tipos mais comuns de estruturas Iteráveis são Listas e Mapas.

Listas

Existem 2 tipos de Sequências em Scala: indexadas (Vector, Range, String,...) e lineares (List, Queue, Stream, Stack). Sequências indexadas são sequencias cujo índice do elemento (posição) está armazenado em uma estrutura ordenada (como uma árvore B, por exemplo), permitindo rápido acesso ao elemento em uma determinada posição. Sequências lineares são sequências onde cada elemento possui apenas seu próprio valor e uma referência a um próximo elemento.

A implementação de Lista em Scala trabalha com a estrutura cabeça e cauda: um elemento (cabeça) e uma referência ao restante da lista (cauda).


In [ ]:
val l = List[Int](1,2,3)
println(l.head)
println(l.tail)

Como a lista é uma sequência linear, não é comum que ela seja utilizada em cenários onde deseja-se acessar um elemento em uma determinada posição, pois, em sequências lineares, isso implica em percorrer todos os elementos até encontrar a posição desejada.

Listas em Scala possuem alguns operadores definidos para manipulá-las:


In [ ]:
val x = List[Int](1,2,3)

println(10 :: x) //adiciona ao início da lista
println(x :+ 10) //adiciona ao fim da lista
println(x ++ List[Int](4,5)) //adiciona a segunda lista ao final da primeira
println(x ::: List[Int](4,5)) //adiciona a segunda lista ao final da primeira

Existem alguns métodos comuns a todas as coleções em Scala, a fim de auxiliar na obtenção de informações:


In [ ]:
val x = List(1,2,3,2,4)
println(x.isEmpty) //está vazia
println(x.nonEmpty) //não está vazia
println(x.length) //tamanho
println(x.contains(1)) //pertinência
println(x.sum) //soma dos elementos
println(x.product) //produto dos elementos
println(x.distinct) //elementos distintos
println(x mkString(",")) //gera uma string utilizando um separador
println(x mkString("(",",",")")) //gera uma string utilizando um separador, uma string para iniciar e uma para finalizar

As coleções iteráveis em Scala também possuem vários métodos de Alta Ordem em comum. Mostraremos alguns desses métodos e como eles funcionam com as listas. O mesmo vale para outras coleções (com algumas modificações no mapa). Para os exemplos a seguir, utilizaremos a seguinte lista:


In [ ]:
val x = List[Int](1,2,3,4,5,6,7,8,9)
  • For Each: aplica uma certa função sobre os elementos da coleção

In [ ]:
x foreach (e => println(e))

OBS: em funções simples como a do exemplo acima, podemos utilizar uma notação simplificada de função anônima, onde a variável é substituída por wildcard( _ ):


In [ ]:
x foreach (println(_))

OBS2: Como a função println já é uma função que recebe apenas um parâmetro, podemos mandá-la diretamente como argumento:


In [ ]:
x foreach (println)
  • Filter: retorna apenas os elementos da coleção que atendem a um certo predicado (função que recebe um elemento da lista e retorna Boolean)

In [ ]:
x.filter(_%2 == 0) //apenas números pares
  • Map: gera uma nova coleção, aplicando uma função sobre cada elemento da lista

In [ ]:
x map (_*2) //lista com o dobro dos elementos
  • Flat Map: é similar ao map, porém, a função aplicada a cada elemento deve retornar uma sequência. No final, todas as sequências são encadeadas como uma só

In [ ]:
List[Int](1,2,3) flatMap (0 to _) //para cada elemento e, retorna a sequência de 0 à e
  • Zip: retorna uma lista de tuplas que combina elementos da coleção com outra

In [ ]:
val y = x zip List[Int](9,8,7,6,5,4,3,2,1)
print(y)
Curiosidade

Para gerarmos, a partir de uma lista, uma nova lista onde cada elemento está junto de seu índice (posição na lista), podemos chamar o método zipWithIndex:


In [ ]:
x zipWithIndex
Curiosidade

Uma coleção de tuplas permite um for um pouco mais expressivo:


In [ ]:
for((a,b) <- y) println(s"($a,$b)")
  • Group By: retorna um mapa onde a chave é o valor de agrupamento (obtido de cada elemento da estrutura) e o valor é uma lista de elementos que pertencem ao grupo.

In [ ]:
x groupBy (_%2)

Mapas

Mapas em Scala são estruturas do formato chave -> valor. Como o nome sugere, dada uma chave k, ela possui um valor correspondente v. Um mapa pode ser gerado de várias formas: utilizando o construtor de Map, uma lista de tuplas de tamanho 2 ou uma lista de bindings (mapeamentos chave, valor)


In [ ]:
val a = Map("a" -> 1, "b" -> 2, "c" -> 3)
val b = List(("d",4),("e",5),("f",6)).toMap
val c = List("g"->7,"h"->8,"i"->9).toMap

Mapas também possuem alguns métodos e operadores já definidos:


In [ ]:
println(a("b")) //obtenção de valor
println(a.getOrElse("j",-1)) //obtenção de valor com valor padrão (caso a chave não exista no mapa)
println(a + ("j" -> 10)) //adição de chave e valor
println(a + ("a" -> 5)) //caso a chave exista, será feita uma sobrescrita no valor
println(a - "a")  //remoção de chave (e valor, por consequência)
println(a.keys) //chaves da coleção
println(a.values) //valores da coleção

Diferente das lista, as iterações feitas sobre mapas são aplicadas sobre o par (chave, valor):


In [ ]:
for((c,v) <- a) print(s"$c -> $v\t")
println()

println(a map (x => x._1 -> 2*x._2) mkString "\t")

println(a mkString "\t")

Exercícios


1. Vamos definir o tipo Conjunto como uma função que recebe um inteiro e retorna um booleano. Se um elemento pertencer ao conjunto, o retorno deverá ser true. Caso contrário, false. Implemente os métodos abaixo a fim de conseguir executar a célula:


In [ ]:
type Set = Int => Boolean

object Operador{
    def conjuntoUnitario(n: Int): Set = 
        x => x == n
    
    def uniao(s: Set, t: Set): Set = 
        x => s(x) || t(x)
    
    def interseccao(s: Set, t: Set): Set = 
        x => s(x) && t(x)
    
    def diferenca(s: Set, t: Set): Set = 
        x => s(x) && !t(x)
    
    def complemento(s: Set): Set = 
        x => !s(x)
}


val s = Operador.conjuntoUnitario(2)
val t = Operador.conjuntoUnitario(1)

//a função require precisa receber o valor true. Caso contrário, ela dispara uma exceção.
require(!s(1))
require(s(2))
require(!t(2))
require(t(1))

val u = Operador.uniao(s,t)

require(u(1))
require(u(2))
require(!u(3))

val i = Operador.interseccao(u,t)

require(i(1))
require(!i(2))
require(!i(3))

val d = Operador.diferenca(u,t)

require(d(2))
require(!d(1))
require(!d(3))

val c = Operador.complemento(u)

require(!c(1))
require(!c(2))
require(c(3))
require(c(4))

println("Parabéns! Sua implementação está correta!")

2. O código abaixo faz a leitura do dataset Iris visto na aula 1 de Jupyter. Transforme as linhas do arquivo em uma case class Amostra , que representa uma linha do dataset. Por fim, compute a média dos 4 atributos em cada classe (setosa, versicolor e virginica)


In [ ]:
//importando o objeto Source, que auxilia na manipulação de arquivos
import scala.io.Source

case class Amostra(sl: Double, sw: Double, pl: Double, pw: Double, c: String)

//lendo as linhas do arquivo
//NOTA: por padrão, o método Souce.fromFile retorna um Iterável que NÂO está totalmente em memória
//NOTA 2: é dado um drop(1) nas linhas para descartar a primeira linha, que é o cabeçalho do dataset
val lines = Source.fromFile("../01-python-jupyter-notebook/iris-dataset.txt").getLines.drop(1)

//utilize o método split(c) para dividir uma String, separando-as pelo caractere c
val amostras = lines
    //separando os valores por tabulação
    .map(_.split("\t"))
    //filtrando apenas as linhas com 5 valores
    .filter(_.length == 5)
    //transformando as linhas em Amosrtas
    .map(l => Amostra(l.head.toDouble, l(1).toDouble, l(2).toDouble, l(3).toDouble, l(4)))
    .toList

//agrupando as amostras pela classe
val amostrasAgrupadas = amostras
    .groupBy(_.c)

//definindo função que calcula a média de uma sequência de Doubles
def mean(values: Seq[Double]): Double = values.sum / values.length

//para cada classe e suas amostras, calcular a média de cada um dos 4 atributos
for((c,amostras) <- amostrasAgrupadas){
    println(s"Analisando classe $c...")
    val slMedia = mean(amostras.map(_.sl))
    println(s"Media do comprimento da sépala: $slMedia")
    val swMedia = mean(amostras.map(_.sw))
    println(s"Media da largura da sépala: $swMedia")
    val plMedia = mean(amostras.map(_.pl))
    println(s"Media do comprimento da pétala: $plMedia")
    val pwMedia = mean(amostras.map(_.pw))
    println(s"Media da largura da pétala: $pwMedia")
    println("-------------------")
}